深入浅出 springboot - 简介和示例

什么是 Spring Boot?

如果把 Java 开发比作开一家餐厅,传统的 Spring 框架就像是你买下了一块空地,你需要自己设计厨房、寻找厨具供应商、装修排烟系统、配置煤气管道。在你炒出第一盘青椒肉丝之前,可能已经忙活了三个月。而 Spring Boot 就是一家 “精装修的共享厨房”。它不仅帮你把炉灶、锅铲、调料都摆好了,甚至连火候都预设到了最合适的状态。你只需要带着食材(业务代码)进去,开火即炒,瞬间出餐。


诞生背景

在 2014 年 Spring Boot 1.0 正式发布之前,Java 开发者正经历着一段 “黑暗时期”:

  1. XML 配置地狱: 那时的 Spring 被戏称为“配置框架”。写一个简单的 Hello World,你可能需要配置数百行的 XML 文件。开发者自嘲:“半天写代码,半天调配置”。
  2. 微服务浪潮的倒逼: 2013 年前后,Martin Fowler 提出了微服务 (Microservices) 概念。微服务要求把一个大系统拆成几十个小服务。如果每个小服务都要折腾半天配置、手动部署 Tomcat,那开发效率将是毁灭性的。
  3. 竞争对手的压力: 当时 Node.js 和 Python 的 Web 框架(如 Express, Flask)以“几行代码启动服务”的简洁性吸引了大量开发者。Spring 必须进化,否则就会被时代抛弃。

于是,Pivotal 团队在 2014 年 推出了 Spring Boot,目标只有一个:让 Spring 再次变得简单。


核心体系

Spring Boot 并不是凭空创造的新技术,它是站在 Spring 这个巨人肩膀上的“自动化管家”。它的核心理念是:约定优于配置 (Convention over Configuration)

  • 自动配置 (Auto-Configuration) —— 告别配置地狱:
    • 以前你需要手动告诉 Spring:“我要用 MySQL,驱动是这个,连接池是那个”。
    • Spring Boot 的出现,就如同智能手机。以前的手机连 WiFi 要设置 IP、子网掩码、网关;现在的手机只要点一下 WiFi 名,剩下的它自己搞定。
    • Spring Boot 启动时会利用 @EnableAutoConfiguration 扫描你引入的 jar 包。如果你引入了 Redis 的包,它就推断你要用 Redis,并自动在内存里帮你创建好 RedisTemplate 实例。
  • 起步依赖 (Starter POMs) —— 依赖管理标准化:
    • 以前引入 A 包,可能需要手动引入 B、C、D 及其对应的兼容版本,经常发生版本冲突(依赖地狱)。
    • 而 Spring Boot 就像点外卖套餐。你不用单点米饭、肉、筷子,直接点一个 “鱼香肉丝套餐”,所有配套的东西由餐厅(Spring 官方)帮你配好。
    • 它将原本分散的依赖进行了垂直打包。比如引入 spring-boot-starter-web,它会自动帮你下载 Spring MVC、Jackson(JSON解析)、Tomcat 等一整套经过官方兼容性测试的组件。
  • 内嵌容器 (Embedded Containers) —— 迈向云原生:
    • 以前部署 Java 应用需要:安装 Tomcat -> 配置环境变量 -> 拷贝 war 包。这在需要快速扩容的云环境中太慢了。
    • 以前你要跑代码,得先买个大冰箱(Tomcat)把菜塞进去。现在 Spring Boot 把冰箱缩小成一个 “车载冷藏箱”,直接集成在菜里,提着就能走。
    • 打包后的 Spring Boot 程序是一个独立的 “.jar” 文件。通过 “java -jar” 命令直接启动,因为服务器(Tomcat/Jetty)已经作为代码的一部分运行了。这是实现 Docker 容器化CI/CD 自动化部署 的前提。


三分钟开发一个 SB 应用

可以参考《Spring Boot 简介及 hello world 应用的编写》《RESTful 简介以及基于 SSM 的完整案例》《Redis 集群搭建之主从哨兵客户端演示》《Redis 集群搭建之集群cluster模式的客户端构建》《Redis 支持读写分离的 RedisJSON 客户端组件》《Redis 集群模式下支持读写分离的 RediSearch 简单自研组件》《Redis 高级客户端 Redisson 的使用》《Shiro的工程实践》等等。


两种 POM 的组织方式

第一种方式:继承派

这是官方最推荐新手使用的方式,也是 “全家桶” 模式。优点是极简且省心,SB 自动配置了 Java 编译版本、编码格式(UTF-8)、资源过滤(Resource Filtering), 预置了大量的插件配置(如 spring-boot-maven-plugin),你不需要自己写插件的版本号。缺点是太霸道,Maven 的 parent 标签是单继承的。如果你所属的公司要求项目必须继承公司统一的 “企业父 POM”(例如 company-parent),这种方式就直接哑火了。

1
2
3
4
5
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.15.RELEASE</version>
</parent>


第二种方式:组合派

这是一种 “按需取经” 的模式。优点是自由解耦,不占用 parent 位置。你的项目可以继承自公司的父 POM,同时依然享受 Spring Boot 的版本管理。它只负责 “统一版本号”,不会强加给你额外的资源过滤配置或插件设置。缺点是稍显琐碎,你需要手动配置 Java 编译版本,且在使用 spring-boot-maven-plugin 等插件时,必须手动指定版本号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>3.5.13</spring-boot.version>
<logback.version>1.5.25</logback.version>
<lombok.version>1.18.42</lombok.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>


在实际正规的商业开发中,通常采用 “公司父 POM + Spring Boot 导入” 的组合模式,这为架构扩展留出了余地。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<project>
<!--父项目-->
<parent>
<groupId>com.yourcompany</groupId>
<artifactId>corporate-parent</artifactId>
<version>1.0.0</version>
</parent>

<!--公共参数-->
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>3.5.9</spring-boot.version>
<org.projectlombok.version>1.18.30</org.projectlombok.version>
<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
<logback.version>1.5.25</logback.version>
</properties>

<!--依赖声明-->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<!--具体依赖-->
<dependencies>
<!--....--->
</dependencies>

<!--插件配置-->
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.demo.App</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${org.projectlombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
<parameters>true</parameters>
</configuration>
</plugin>
</plugins>
</build>

<!--远程依赖仓库和插件仓库-->
<repositories>
<repository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases>
<enabled>true</enabled>
</releases>
</repository>
<repository>
<id>central</id>
<name>Central Repository</name>
<url>https://repo.maven.apache.org/maven2</url>
<layout>default</layout>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>


关于依赖的查找顺序

Maven 并不是同时对比所有仓库看谁优先级高,而是按照 由近及远 的顺序查找:

  • 本地仓库 (Local Repository):你的 localRepository 文件夹(默认 .m2/repository)。如果有,直接用。
  • 远程仓库 (Remote Repositories):如果本地没有,就开始找远程的。寻找顺序如下:
    • 第一步:检查 settings.xml 中的 profiles。
    • 第二步:检查 pom.xml 中的 repositories。
    • 第三步:如果以上都没配置,最后找 Maven 默认的 Central。
  • mirrorOf 不是参与排队的优先级,它是强行拦截:不管你在 pom.xml 里配置了多少个 repository,一旦 settings.xml 中配置了 *(镜像所有),Maven 会无视你配置的所有 URL,强行把请求发往镜像地址。

执行远程访问时,如果在 settings.xml 的 activeProfiles 里配置了仓库,它们最先被访问。settings.xml 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">

<localRepository>/Library/apache-maven-3.8.5/repository</localRepository>

<interactiveMode>true</interactiveMode>
<pluginGroups>
<pluginGroup>org.springframework.boot</pluginGroup>
</pluginGroups>

<servers>
<server>
<id>owlias-snapshots</id>
<username>xxx</username>
<password>xxx</password>
</server>
<server>
<id>owlias-releases</id>
<username>xxx</username>
<password>xxx</password>
</server>
</servers>

<mirrors>
<mirror>
<id>aliyun-mirror</id>
<mirrorOf>central</mirrorOf>
<name>阿里云公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>

<!--这里排除了 Spring 的里程碑和快照仓库,让它们直接从 Spring 官方仓库下载
<mirror>
<id>aliyun-mirror</id>
<mirrorOf>*,!spring-milestones,!spring-snapshots</mirrorOf>
<name>阿里云公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
-->
</mirrors>

<profiles>
<profile>
<id>oss-development</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>

<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>

<repository>
<id>spring-snapshots</id>
<url>https://repo.spring.io/snapshot</url>
<releases><enabled>false</enabled></releases>
<snapshots>
<enabled>true</enabled>
<updatePolicy>interval:15</updatePolicy>
</snapshots>
</repository>
</repositories>

<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
</pluginRepository>
<pluginRepository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>

<activeProfiles>
<activeProfile>oss-development</activeProfile>
</activeProfiles>
</settings>

POM 文件中的 Profiles:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<!--第一步:在 pom.xml 中定义 Profile-->
<profiles>
<!--多环境配置切换:dev-->
<profile>
<id>dev</id>
<properties>
<env.type>dev</env.type>
</properties>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<!--多环境配置切换:prod-->
<profile>
<id>prod</id>
<properties>
<env.type>prod</env.type>
</properties>
<distributionManagement> <!--执行命令,实现上传:mvn clean deploy -Pprod-->
<repository>
<id>owlias-releases</id>
<url>http://xxx:8081/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>owlias-snapshots</id>
<url>http://xxx:8081/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
</profile>
</profiles>

<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>


<!--第二步:在 Spring Boot 配置文件中使用占位符-->
<!--在你的 src/main/resources/application.yml 中,不再写死具体的环境配置,而是引用 Maven 的变量。--.
spring:
profiles:
# 这里会根据 Maven 打包时的 Profile 自动替换成 dev 或 prod
active: @env.type@

<!--第三步:创建对应的环境文件-->
<!--在同一目录下创建两个具体的文件:-->
<!--application-dev.yml (配置本地 localhost 数据库)-->
<!--application-prod.yml (配置云端高可用数据库)-->

<!--第四步:当你作为开发者在本地运行时,直接启动即可,因为 dev 是默认激活的。-->
<!--但是,当你需要部署到 GitHub Pages 或者自己的服务器时,你只需要在命令行输入:-->
<!-- mvn clean package -Pprod -->


构建一个最基础的应用框架

这个初始项目主要介绍了一些开发中的一些常用技术,包括:怎样热部署、swagger文档管理、典型的分环境配置文件、springmvc常用技术、典型的logback-spring日志、MybatisPlus型ORM框架(内置接口及简化开发方式、创建和更新时间处理、业务字段、敏感字段、逻辑删除字段、枚举字段、自定义mapper接口或配置接口、分页插件和自定义分页查询、简单多表查询)、参数各种校验及分组校验、lombok和mapstruct简化开发等。

依赖配置

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.9</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.koohub.demo01</groupId>
<artifactId>demo01-init-springboot-project</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo01-init-springboot-project</name>

<properties>
<java.version>17</java.version>
<org.projectlombok.version>1.18.30</org.projectlombok.version>
<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>

<dependencies>
<!--热部署-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>

<!--测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!--springmvc-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--参数校验-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${org.projectlombok.version}</version>
<scope>provided</scope>
</dependency>

<!--mapstruct-->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
<scope>provided</scope>
</dependency>

<!--swagger springboot3 只需要引入依赖就可以-->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.16</version>
</dependency>

<!--mybatis plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
<!--druid数据库连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.16</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<!--总装插件-->
<!--只有包含 main 方法的启动模块(例如 owlias-admin)才需要这个插件-->
<!--负责把代码、所有依赖的第三方jar包、甚至Tomcat服务器全部塞进一个jar文件里-->
<!--打包时自动生成一个特殊的 MANIFEST.MF 文件,使得 jar 包具备了自我引导的能力-->
<!--你只需要一个 java -jar app.jar 命令,应用就能在任何装了 JDK 的地方跑起来。-->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<excludeDevtools>true</excludeDevtools>
</configuration>
</plugin>
<!--编译插件-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<!-- springboot 确保在编译时,Lombok先生成 Getter/Setter,
MapStruct再根据这些方法生成转换代码。需要配合 maven-compiler-plugin -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${org.projectlombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<!--强制让编译器在编译时保留参数名信息-->
<!--Spring Boot 3 之后不再支持通过字节码解析来获取未命名的参数名。在旧版本中,Spring 可以通过 ASM 字节码
技术推断出参数名,但新版本要求必须通过 Java 反射获取,而这需要你在编译时显式开启 -parameters 参数-->
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
<parameters>true</parameters>
</configuration>
</plugin>
<!--质检插件-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<skipTests>false</skipTests>
<testFailureIgnore>true</testFailureIgnore>
</configuration>
</plugin>
<!--项目规制插件-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<id>enforce-no-javax-servlet</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<message>
检测到 Owlias 中混入了旧版的 javax.servlet 依赖,必须使用 jakarta.servlet。
请检查 Dependency Analyzer 找出是谁引入了 shiro-web (javax版) 或
javax.servlet-api!
</message>
<excludes>
<exclude>javax.servlet:javax.servlet-api</exclude>
<exclude>javax.servlet:servlet-api</exclude>
<exclude>org.apache.shiro:shiro-web:*:jar:!(jakarta)</exclude>
</excludes>
</bannedDependencies>
</rules>
<fail>true</fail>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>


日志文件配置

src/main/resources/logback-spring.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<!--scan=true:改动 logback-spring.xml 无需重启项目,日志策略会立刻生效-->
<property name="LOG_PATH" value="./logs"/>
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:- }) --- [%15.15thread] %cyan(%-40.40logger{39}) : %m%n"/>
<property name="FILE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:- } --- [%thread] %logger{50} - [%method,%line] - %m%n"/>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>

<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/sys-info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/sys-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>30GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>DENY</onMatch>
<onMismatch>ACCEPT</onMismatch>
</filter>
</appender>

<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/sys-error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/sys-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>

<!--所有环境的公共配置-->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
</configuration>


主要配置文件

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
spring:
application:
name: owlias
main:
banner-mode: CONSOLE
profiles:
active: dev
messages:
basename: static/i18n/messages
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss

server:
port: 8848
context-path: /
tomcat:
uri-encoding: UTF-8
accept-count: 1000
threads:
max: 800
min-spare: 100

spring:
application:
name: demo01
main:
banner-mode: CONSOLE
profiles:
active: dev

logging:
level:
# 屏蔽 Spring 默认的 404 警告日志,除非发生真正的 ERROR
org.springframework.web.servlet.PageNotFound: ERROR
# 也可以屏蔽异常解析器的详细解决记录,除非发生真正的 ERROR
org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: ERROR

application-dev.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
logging:
level:
root: info
com.koohub: debug
com.koohub.demo01.mapper: info
org.springframework.web: info
org.springframework: info

spring:
# 热部署配置
devtools:
restart:
# 开启热部署,注意开发环境关掉
enabled: true
# 热部署的文件夹,此文件夹内的文件发生变更时会发生热部署
additional-paths: src/main/java, src/main/resources
# 热部署需要排除的文件夹
exclude: static/**
# 静态文件配置
mvc:
# 静态文件的路径过滤规则
static-path-pattern: static/**
web:
resources:
# 静态文件的文件夹位置
static-locations: classpath:/templates/,classpath:/static
# 文件上传限制
servlet:
multipart:
# 每个文件的大小限制,默认为1MB
max-file-size: 10MB
# 单次请求的大小限制,默认为1MB
max-request-size: 10MB
# 数据库相关信息
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://xxx:3306/zdemo?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: xxx
password: xxx

# Swagger (SpringDoc) 相关配置
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
openapi:
info:
# 利用变量展示当前环境
title: Springboot3简单项目的演示-${spring.profiles.active}-接口文档
description: Springboot3简单项目的演示文档。
version: 1.0.0
contact:
name: 技术支持-KJ
email: support@koohub.com
license:
name: Apache 2.0
external-docs:
description: 项目设计文档
url: https://github.com/koohub/docs
group-configs:
- group: 'default'
paths-to-match: '/**'
packages-to-scan: com.koohub.demo01.controller


mybatis-plus:
global-config:
banner: false
db-config:
logic-delete-field: deleted # 全局逻辑删除字段名(实体类属性名)
logic-not-delete-value: 0 # 逻辑未删除值
logic-delete-value: 1 # 逻辑删除值
# 映射文件路径
mapper-locations: classpath:/mapper/*.xml
# 实体类所在包,用于 XML 中的简写(如 resultType="User")
type-aliases-package: com.koohub.demo01.model.entity
configuration:
# 使全局的映射器启用或禁用缓存
cache-enabled: true
# 配置默认的执行器(SIMPLE/REUSE/BATCH)
default-executor-type: simple
# 如果你确定所有枚举都用了 @EnumValue,这行其实可以不写,它默认就是用 MybatisEnumTypeHandler
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
# 开启驼峰转下划线(例如 Java 中的 username 对应 DB 的 user_name)
map-underscore-to-camel-case: true
# 开发环境日志打印SQL,可以使用 logging.level 代替
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

application-prod.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
spring:
# 生产环境关闭热部署
devtools:
restart:
enabled: false
mvc:
static-path-pattern: static/**
web:
resources:
static-locations: classpath:/templates/,classpath:/static
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://xxx:3306/zdemo?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: xxx
password: xxx

# 生产环境关闭swagger
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false

mybatis-plus:
global-config:
banner: false
db-config:
logic-delete-field: deleted
logic-not-delete-value: 0
logic-delete-value: 1
mapper-locations: classpath:/mapper/*.xml
type-aliases-package: com.koohub.demo01.model.entity
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
map-underscore-to-camel-case: true


启动类

1
2
3
4
5
6
@SpringBootApplication
public class Demo01Application {
public static void main(String[] args) {
SpringApplication.run(Demo01Application.class, args);
}
}


主要配置类

WebConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import com.koohub.demo01.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 拦截器值越小拦截器越靠前
registry.addInterceptor(new LoginInterceptor()).order(1).addPathPatterns("/api/test/**"); // 这里只做演示
}

@Override
public void addCorsMappings(CorsRegistry registry) { // 跨域全局设置
// 在大型生产环境中,通常不在 Java 代码里配置 CORS,而是放在架构的前端节点处理
// Nginx 方案:在 Nginx 配置文件中统一添加 add_header 'Access-Control-Allow-Origin'
// 网关方案 (Spring Cloud Gateway):在网关层统一处理跨域,后端微服务就无需感知跨域问题,性能更好且易于维护。
registry.addMapping("/**")
// 1. 生产环境需从配置文件读取合法的域名列表
// 当一个请求声明要包含凭证时,浏览器要求服务器必须返回一个具体的 Access-Control-Allow-Origin(例如 http://localhost:5173),而不能是通配符 *。
// 结果浏览器发现后端返回的是 *,为了保护用户隐私,它会认为这个响应是不安全的,从而拒绝将数据交给 Axios。
//.allowedOrigins("*")
// 如果你希望保持“允许所有域名”的灵活性,同时又要支持 allowCredentials(true),请将 .allowedOrigins("*") 改为 .allowedOriginPatterns("*")。
.allowedOriginPatterns("*")
// 2. 明确支持的方法
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
// 3. 建议明确允许的 Header,而不是全部允许
.allowedHeaders("*")
// 4. 允许携带 Cookie
.allowCredentials(true)
// 5. 预检请求有效期,建议设置 1-2 小时即可
.maxAge(7200);
}
}

MybatisPlusConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@MapperScan("com.koohub.demo01.mapper")
public class MybatisPlusConfig {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 创建分页拦截器并指定数据库类型
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
// 将分页拦截器添加到主拦截器中
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
}

MybatisPlusHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.util.Date;

/**
* @author KJ
* @description 自动填充只对 MyBatis-Plus 原生方法有效,
* 但对于你在 XML 里手写的 <insert> 或 <update> SQL 语句,或者手写的 @Update("UPDATE...") 无效
*/
@Component
public class MybatisPlusHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
// 插入时,填充 createTime 和 updateTime 为当前时间
this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
this.strictInsertFill(metaObject, "updateTime", Date.class, new Date());
// 填充 deleted 字段
this.strictInsertFill(metaObject, "deleted", Integer.class, 0);
}

@Override
public void updateFill(MetaObject metaObject) {
// 更新时,填充 updateTime 为当前时间
this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
}
}

MapStructConfig

1
2
3
4
5
6
import org.mapstruct.MapperConfig;
import org.mapstruct.ReportingPolicy;

@MapperConfig(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public class MapStructConfig {
}


公共的 Model

BaseEntity

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Accessors(chain = true)
public class BaseEntity implements Serializable {

@TableField(fill = FieldFill.INSERT) // 插入时填充
private Date createTime;

@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时填充
private Date updateTime;

@TableLogic
private int deleted;
}

BasePageQuery

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@Schema(description = "公共分页查询基础类")
public class BasePageQuery implements Serializable {

@Schema(description = "当前页码", example = "1")
private Integer pageNum = 1;

@Schema(description = "每页条数", example = "10")
@Max(value = 200, message = "单页条数超限")
private Integer pageSize = 10;

// 获取分页偏移量,用于 XML 中的 LIMIT #{offset}, #{pageSize}
@Schema(hidden = true)
public Integer getOffset() {
return (pageNum - 1) * pageSize;
}
}

PageResult

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "统一分页返回结果")
public class PageResult<T> implements Serializable {

@Schema(description = "总记录数")
private Long total;

@Schema(description = "结果列表")
private List<T> list;

public static <T> PageResult<T> of(Long total, List<T> list) {
return new PageResult<>(total, list);
}
}

ValidGroups

1
2
3
4
5
6
7
public interface ValidGroups {
// 新增分组
interface Insert {}

// 更新分组
interface Update {}
}

Result

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Data
public class Result<T> implements Serializable {
private int code;
private String msg;
private T data;
private long timestamp;

private Result() {
this.timestamp = System.currentTimeMillis();
}

// 成功返回(带数据)
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
if (data == null) {
result.setCode(ResultCode.NO_DATA.getCode());
result.setMsg(ResultCode.NO_DATA.getMessage());
return result;
}
result.setCode(ResultCode.SUCCESS.getCode());
result.setMsg(ResultCode.SUCCESS.getMessage());
result.setData(data);
return result;
}

// 成功返回(不带数据)
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMsg(ResultCode.SUCCESS.getMessage());
return result;
}

// 失败返回(自定义错误码和消息)
public static <T> Result<T> fail(int code, String msg) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMsg(msg);
return result;
}

// 失败返回(使用枚举)
public static <T> Result<T> fail(ResultCode resultCode) {
return fail(resultCode.getCode(), resultCode.getMessage());
}
}

ResultCode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Getter
public enum ResultCode {
FAILURE(-1, "业务异常"),
SUCCESS(0, "操作成功"),
NO_DATA(2, "数据不存在"),
VALID_ERROR(3, "参数异常"),
UNAUTHORIZED(401, "暂无权限"),
NOT_FOUND(404, "资源不存在"),
INTERNAL_ERROR(500, "服务器错误");

private final int code;
private final String message;

ResultCode(int code, String message) {
this.code = code;
this.message = message;
}
}


字典或枚举

OrderStatusEnum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;

@Getter
public enum OrderStatusEnum {

UNPAID(0, "待支付"),
PAID(1, "已支付"),
CANCELLED(2, "已取消"),
REFUNDED(3, "已退款");

OrderStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}

/**
* @EnumValue 告诉 MyBatis-Plus 数据库中存储的是这个 code 值
*/
@EnumValue
private final int code;

/**
* @JsonValue 告诉 Jackson (SpringMVC) 在返回 JSON 给前端时显示这个 desc 描述
* 这样前端收到的就是 "待支付" 而不是 "UNPAID"
*/
@JsonValue
private final String desc;
}


异常处理类

GlobalExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import com.koohub.demo01.model.common.Result;
import com.koohub.demo01.model.common.ResultCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.builder.BuilderException;
import org.mybatis.spring.MyBatisSystemException;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.Comparator;

@Slf4j
@RestControllerAdvice // = @ControllerAdvice + @ResponseBody,本质区别在于响应的方式
public class GlobalExceptionHandler {

/**
* 参数校验异常合并处理
*/
@ExceptionHandler({
MethodArgumentNotValidException.class,
HandlerMethodValidationException.class,
ConstraintViolationException.class
})
public Result<String> handleValidationExceptions(Exception ex) {
String message = "参数校验失败";
if (ex instanceof MethodArgumentNotValidException e) {
// 处理对象校验 (如 User user)
message = e.getBindingResult().getFieldErrors().stream()
.min(Comparator.comparing(FieldError::getField))
.map(FieldError::getDefaultMessage)
.orElse(message);
} else if (ex instanceof HandlerMethodValidationException e) {
// 处理注解在方法上的 validated 路径参数校验
message = e.getValueResults().stream()
.flatMap(res -> res.getResolvableErrors().stream())
.map(MessageSourceResolvable::getDefaultMessage)
.findFirst().orElse(message);
} else if (ex instanceof ConstraintViolationException e) {
// 处理注解在类上的 validated 拦截到的单参数校验异常
message = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.findFirst().orElse(message);
}
log.warn("参数校验未通过: {}", message);
return Result.fail(ResultCode.VALID_ERROR.getCode(), message);
}

/**
* 捕获 MyBatis 解析异常 (例如 #{} 引起的语法错误)
* 这些异常在初始化或执行 SQL 时会包装在 PersistenceException 或 MyBatisSystemException 中
*/
@ExceptionHandler({MyBatisSystemException.class, BuilderException.class})
public Result<String> handleMyBatisException(Exception e, HttpServletRequest request) {
log.error("数据库映射/语法异常 [{}]: ", request.getRequestURI(), e);
return Result.fail(ResultCode.INTERNAL_ERROR.getCode(), "服务忙,请稍后重试"); // 对外屏蔽具体的 SQL 报错细节,防止暴露数据库表结构
}

/**
* 业务运行时异常 (建议以后定义一个 BaseException)
*/
@ExceptionHandler(RuntimeException.class)
public Result<String> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
log.warn("业务运行时异常 [{}]: {}", request.getRequestURI(), e.getMessage()); // 业务抛出的异常通常是预期的,可以用 WARN
return Result.fail(ResultCode.FAILURE.getCode(), e.getMessage());
}

/**
* 处理 Spring Boot 3 路径找不到异常 (404)
* 避免其进入 Exception.class 变成 500
*/
@ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class})
public Result<Void> handleNotFoundException(Exception e, HttpServletRequest request) {
// 过滤掉 favicon.ico 以及 Chrome 插件产生的 .well-known 探测
String uri = request.getRequestURI();
if (uri.endsWith("/favicon.ico") || uri.contains(".well-known")) {
return Result.success();
}
return Result.fail(ResultCode.NOT_FOUND);
}

/**
* 兜底异常处理器 (防止 500 错误直接暴露堆栈)
*/
@ExceptionHandler(Exception.class)
public Result<String> handleDefaultException(Exception e, HttpServletRequest request) {
log.error("服务器内部错误,请求路径: [{}]", request.getRequestURI(), e);
return Result.fail(ResultCode.INTERNAL_ERROR);
}
}


拦截器

LoginInterceptor

1
2
3
4
5
6
7
8
9
10
11
public class LoginInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("login?");
if (true) {
return true;
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}


文件的上传下载业务

UploadController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import com.koohub.demo01.model.common.Result;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;

@RestController
public class UploadController {
// private static final String UPLOAD_PATH = System.getProperty("user.dir") + "/upload/";
private static final String CONTEXT_PATH = "/upload/";


@PostMapping("/up")
public Result<Void> upload(String nickname, MultipartFile photo, HttpServletRequest request) throws IOException {
System.out.println("文件大小:" + photo.getSize());
System.out.println("文件类型:" + photo.getContentType());
System.out.println("文件名称:" + photo.getOriginalFilename());

// 这种方式每次生成的服务器context地址都不是固定的,随着服务器的地址变动而改变,真正生产环境是需要一个真正的oss服务器
String path = request.getServletContext().getRealPath(CONTEXT_PATH);
System.out.println(path);
saveFile(photo, path);
return Result.success();
}

private void saveFile(MultipartFile file, String path) throws IOException {
File f = new File(path);
if (!f.exists()) {
f.mkdir();
}
File targetFile = new File(path + file.getOriginalFilename());
file.transferTo(targetFile);
}

@GetMapping("/view")
public ResponseEntity<Resource> viewFile(@RequestParam String nickname, HttpServletRequest request) throws IOException {
// 1. 确定外部存储的根路径(建议配置在 properties 中)
String realPath = request.getServletContext().getRealPath(CONTEXT_PATH);
File file = new File(realPath + nickname);

if (!file.exists()) {
return ResponseEntity.notFound().build();
}

// 2. 将本地文件包装成 Spring 的 Resource 对象
Resource resource = new FileSystemResource(file);

// 3. 动态获取文件的 Content-Type (如 image/jpeg, application/pdf)
String contentType = Files.probeContentType(file.toPath());
if (contentType == null) {
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
}

// 4. 返回 ResponseEntity
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.body(resource);
}
}


用户业务

UserRestController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Tag(name = "用户管理", description = "用户增删改查接口")
@Validated // 只针简单参数的校验
@RestController
//@CrossOrigin // 这个类中的方法允许跨域请求,可以用这个单独的注解解决这个类局部的跨域请求,一般在全局统一设置比较容易管理
public class UserRestController {

@Resource
private UserService userService;

@Operation(summary = "根据ID获取用户")
@GetMapping(value = "/user/{id}")
public Result<UserVO> getUserById(@Min (value = 1, message = "ID不合法") @Parameter(description = "用户编号") @PathVariable Integer id) {
UserVO user = userService.findById(id);
return Result.success(user);
}

@Operation(summary = "获取用户列表")
@GetMapping(value = "/users")
public Result<List<UserVO>> getUsers() {
List<UserVO> users = userService.getAllUser();
return Result.success(users);
}

@Operation(summary = "分页查询用户(自定义 mapper 实现)")
@GetMapping("/page_query")
public PageResult<UserVO> pageQuery(@Validated UserPageQueryRequest pageQuery) {
PageResult<UserVO> page = userService.pageQuery(pageQuery);
return page;
}

@Operation(summary = "分页查询用户(自定义 mapper 实现,IPage 作为第一个参数,不必写 count 查询)")
@GetMapping("/page_query2")
public PageResult<UserVO> pageQuery2(@Validated UserPageQueryRequest pageQuery) {
PageResult<UserVO> page = userService.pageQuery2(pageQuery);
return page;
}

@Operation(summary = "分页查询用户(内置 page query 实现)")
@GetMapping("/page_query3")
public PageResult<UserVO> pageQuery3(@Validated UserPageQueryRequest pageQuery) {
PageResult<UserVO> page = userService.pageQuery3(pageQuery);
return page;
}

@Operation(summary = "新增用户", description = "传入用户信息进行保存")
@PostMapping("/user") // 对于对象形式的参数,如果需要校验,则对象前面必须声明 @validated
public Result<Void> saveUser(@Validated(ValidGroups.Insert.class) UserRequest user) {
userService.saveUser(user);
return Result.success();
}

@PutMapping("/user")
public Result<Void> updateUser(@Validated(ValidGroups.Update.class) UserRequest user) {
userService.updateUser(user);
return Result.success();
}

@Operation(summary = "批量刷新密码", description = "批量刷新密码")
@PutMapping("/batch_refresh_passwd")
public Result<Void> batchRefreshPassword(@Validated @RequestBody BatchRefreshPasswordRequest req) {
userService.batchUpdatePassword(req.getIds(), req.getNewPassword());
return Result.success();
}

@DeleteMapping("/user/{id}")
public Result<Void> deleteUser(@Min (value = 1, message = "ID不合法") @PathVariable Integer id) {
boolean result = userService.removeById(id);
return result ? Result.success() : Result.fail(ResultCode.NOT_FOUND);
}
}

UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import com.baomidou.mybatisplus.extension.service.IService;
import com.koohub.demo01.model.common.PageResult;
import com.koohub.demo01.model.entity.UserEntity;
import com.koohub.demo01.model.req.UserPageQueryRequest;
import com.koohub.demo01.model.req.UserRequest;
import com.koohub.demo01.model.vo.UserVO;
import java.math.BigDecimal;
import java.util.List;

public interface UserService extends IService<UserEntity> {
UserVO findById(Integer id);
List<UserVO> getAllUser();
PageResult<UserVO> pageQuery(UserPageQueryRequest pageQuery);
PageResult<UserVO> pageQuery2(UserPageQueryRequest pageQuery);
PageResult<UserVO> pageQuery3(UserPageQueryRequest pageQuery);
void saveUser(UserRequest userRequest);
void updateUser(UserRequest userRequest);
int batchUpdatePassword(List<Integer> ids, String newPassword);
int reduceBalance(Integer userId, BigDecimal amount);
}

UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {

@Resource
private UserConvert userConvert;


public UserVO findById(Integer id) {
UserEntity userEntity = baseMapper.selectById(id);
UserVO userVo = userConvert.toVO(userEntity);
return userVo;
}

public List<UserVO> getAllUser() {
List<UserEntity> userEntities = baseMapper.selectList(null);
List<UserVO> userVoList = userConvert.toVOList(userEntities);
return userVoList;
}

@Override
public PageResult<UserVO> pageQuery(UserPageQueryRequest pageQuery) {
// 1. 查询总条数
Long total = baseMapper.pageQueryCount(pageQuery);
// 2. 如果总数大于0,则查询当页数据
List<UserVO> voList = Collections.emptyList();
if (total > 0) {
List<UserEntity> entityList = baseMapper.pageQuery(pageQuery);
voList = userConvert.toVOList(entityList);
}
return PageResult.of(total, voList);
}

@Override
public PageResult<UserVO> pageQuery2(UserPageQueryRequest pageQuery) {
IPage<UserVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
IPage<UserVO> resultPage = baseMapper.selectUserPage2(page, pageQuery);
return PageResult.of(resultPage.getTotal(), resultPage.getRecords());
}

@Override
public PageResult<UserVO> pageQuery3(UserPageQueryRequest pageQuery) { // 【注意】需要 mybatis plus PaginationInnerInterceptor 分页拦截器的配合
IPage<UserEntity> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());

/*QueryWrapper<UserEntity> query = new QueryWrapper<>();
query.like("username", pageQuery.getUsername());
query.eq("birthday", pageQuery.getStartTime());*/

LambdaQueryWrapper<UserEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.like(pageQuery.getUsername()!= null, UserEntity::getUsername, pageQuery.getUsername())
.eq(pageQuery.getStartTime() != null, UserEntity::getBirthday, pageQuery.getStartTime());

IPage<UserEntity> resultPage = baseMapper.selectPage(page, queryWrapper);
long total = resultPage.getTotal();

List<UserVO> voList = Collections.emptyList();
if (total > 0) {
voList = userConvert.toVOList(resultPage.getRecords());
}
return PageResult.of(resultPage.getTotal(), voList);
}

@Override
public void saveUser(UserRequest userRequest) {
UserEntity user = userConvert.toEntity(userRequest);
baseMapper.insert(user.setId(null)); // 返回的是影响数据的行数,主键id是直接放在user对象中的
log.info("主键ID:{}", user.getId());
}

@Override
public void updateUser(UserRequest userRequest) {
UserEntity user = userConvert.toEntity(userRequest);
baseMapper.updateById(user); // 返回的是影响数据的行数
}

@Override
public int batchUpdatePassword(List<Integer> ids, String newPassword) {
int result = baseMapper.batchUpdatePassword(ids, newPassword);
return result;
}

@Override
public int reduceBalance(Integer userId, BigDecimal amount) {
int result = baseMapper.reduceBalance(userId, amount);
return result;
}
}

UserConvert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import com.koohub.demo01.config.MapStructConfig;
import com.koohub.demo01.model.entity.UserEntity;
import com.koohub.demo01.model.req.UserRequest;
import com.koohub.demo01.model.vo.UserVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.List;

@Mapper(config = MapStructConfig.class)
public interface UserConvert {

@Mapping(target = "birthday", dateFormat = "yyyy-MM-dd")
UserVO toVO(UserEntity userEntity);
List<UserVO> toVOList(List<UserEntity> userEntities);

@Mapping(target = "birthday", source = "birthday", dateFormat = "yyyy-MM-dd")
UserEntity toEntity(UserRequest userRequest);
}

UserMapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.koohub.demo01.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.koohub.demo01.model.entity.UserEntity;
import com.koohub.demo01.model.req.UserPageQueryRequest;
import com.koohub.demo01.model.vo.UserVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {

@Select("SELECT YEAR(birthday) as year, COUNT(*) as count " +
" FROM zdemo01_user " +
" GROUP BY YEAR(birthday) " +
" ORDER BY year DESC")
List<Map<String, Object>> selectCountByBirthYear();

/**
* 分页查询数据总条数
*/
Long pageQueryCount(@Param("query") UserPageQueryRequest pageQuery);

/**
* 分页查询
*/
List<UserEntity> pageQuery(@Param("query") UserPageQueryRequest pageQuery);

/**
* 分页查询2
*/
IPage<UserVO> selectUserPage2(IPage<UserVO> page, @Param("query") UserPageQueryRequest query);

/**
* 批量更新密码
*/
int batchUpdatePassword(@Param("ids") List<Integer> ids, @Param("newPassword") String newPassword);

/**
* 扣减余额
* SQL中判断 balance >= amount 是防止余额变负数的关键
*/
@Update("UPDATE zdemo01_user SET balance = balance - #{amount} WHERE id = #{userId} AND balance >= #{amount} AND deleted = 0")
int reduceBalance(@Param("userId") Integer userId, @Param("amount") BigDecimal amount);
}

resources/mapper/UserMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.koohub.demo01.mapper.UserMapper">

<resultMap id="BaseResultMap" type="com.koohub.demo01.model.entity.UserEntity">
<id column="id" property="id" jdbcType="INTEGER"/>
<result column="username" property="username" jdbcType="VARCHAR"/>
<result column="password" property="password" jdbcType="VARCHAR"/>
<result column="birthday" property="birthday" jdbcType="TIMESTAMP"/>
<result column="balance" property="balance" jdbcType="DECIMAL"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
</resultMap>

<sql id="Base_Column_List">
id, username, password, birthday, balance, create_time, update_time
</sql>
<sql id="Base_Logic_Condition">
deleted = 0
</sql>
<sql id="User_Page_Where_Clause">
<where>
<include refid="Base_Logic_Condition" />
<if test="query.username != null and query.username != ''">
AND username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.startTime != null">
AND birthday &gt;= #{query.startTime}
</if>
</where>
</sql>

<select id="pageQueryCount" resultType="java.lang.Long">
SELECT
COUNT(*)
FROM zdemo01_user
<include refid="User_Page_Where_Clause"/>
</select>

<select id="pageQuery" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM zdemo01_user
<include refid="User_Page_Where_Clause"/>
ORDER BY id DESC
LIMIT #{query.offset}, #{query.pageSize}
</select>

<select id="selectUserPage2" resultType="com.koohub.demo01.model.vo.UserVO">
SELECT
<include refid="Base_Column_List"/>
FROM zdemo01_user
<include refid="User_Page_Where_Clause"/>
ORDER BY id DESC
</select>

<update id="batchUpdatePassword">
UPDATE zdemo01_user
SET password = #{newPassword}, update_time = NOW()
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</update>
</mapper>

UserEntity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@TableName("zdemo01_user")
public class UserEntity extends BaseEntity {
// 指定主键自增策略
@TableId(type = IdType.AUTO)
private Integer id;

@TableField(value = "username", whereStrategy = FieldStrategy.NOT_NULL)
private String username;

// 在进行 select * 查询时,是否包含该字段。常用于 密码、密钥 等敏感字段,防止意外泄露到前端
@TableField(select = false)
private String password;

@TableField(whereStrategy = FieldStrategy.NOT_NULL)
private Date birthday;

@TableField(whereStrategy = FieldStrategy.NOT_NULL)
private BigDecimal balance;

// 数据库里没这个字段,仅供业务逻辑使用
@TableField(exist = false)
private List<OrderEntity> orders;
}

BatchRefreshPasswordRequest

1
2
3
4
5
6
7
8
9
10
11
12
@Schema(description = "批量刷新用户密码请求")
@Data
public class BatchRefreshPasswordRequest {

@Schema(description = "用户id列表")
@NotEmpty(message = "用户id列表不能为空")
private List<Integer> ids;

@Schema(description = "新密码")
@NotEmpty(message = "新密码不能为空")
private String newPassword;
}

UserPageQueryRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import com.koohub.demo01.model.common.BasePageQuery;
import com.koohub.demo01.model.vo.UserOrderVO;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Date;

@Schema(description = "用户分页查询请求信息实体")
@Data
@EqualsAndHashCode(callSuper = true)
public class UserPageQueryRequest extends BasePageQuery {
@Schema(description = "用户", example = "张三")
private String username;

@Schema(description = "开始日期", example = "2023-11-11")
// 将前端传来的“日期字符串”转换成后端的“Date/LocalDateTime对象”
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date startTime;

@Valid // 如果需要校验关联类的字段需要添加注解;对于父类则会默认校验
private UserOrderVO otherConditions;
}

UserRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Schema(description = "用户请求信息实体")
@Data
public class UserRequest {

@Schema(description = "用户编号(新增时不传)", example = "10", minimum = "10", maximum = "100")
@Null(message = "新增时不能指定ID", groups = ValidGroups.Insert.class)
@NotNull(message = "更新时用户ID不能为空", groups = ValidGroups.Update.class)
private Integer id;

@Schema(description = "用户", example = "张三")
@NotNull(message = "新增时用户名称不能为空", groups = ValidGroups.Insert.class)
@Size(min = 2, message = "名字长度不合法")
private String username;

@Schema(description = "密码", example = "123")
@NotBlank(message = "新增时才强制密码不能为空", groups = ValidGroups.Insert.class)
@Pattern(regexp="[0-9a-zA-Z@$#&]{8,}", message = "密码太简单")
private String password;

@Schema(description = "出生日期", example = "2023-11-11")
@Pattern(regexp="\\d{4}-\\d{1,2}-\\d{1,2}", message = "出生日期格式有问题")
private String birthday;
}

UserVO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Schema(description = "用户响应信息实体")
@Data
public class UserVO { // 一般spring场景下使用 Validated 校验,支持分组校验;除非在对象中潜逃了对象,必须使用 valid!

@Schema(description = "用户编号", example = "10", minimum = "10", maximum = "100")
private Integer id;

@Schema(description = "用户", example = "张三")
private String username;

@Schema(description = "密码", example = "123")
private String password;

@Schema(description = "出生日期", example = "2023-11-11")
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") // 确保转成 JSON 时格式正确
private String birthday;
}


订单业务

OrderRestController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Tag(name = "订单管理", description = "订单相关接口")
@Validated
@RestController
public class OrderRestController {

@Resource
private OrderService orderService;

@GetMapping("/order/user_order_info/{userId}")
public Result<List<UserOrderVO>> getUserOrderInfo(@PathVariable("userId") Integer userId) {
List<UserOrderVO> resultList = orderService.getUserOrders(userId);
return Result.success(resultList);
}

@PostMapping("/order/submit_order")
public Result<Void> submitOrder(@RequestBody OrderRequest orderRequest) {
return orderService.submitOrder(orderRequest);
}
}

OrderService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface OrderService extends IService<OrderEntity> {
/**
* 获取用户及其所有订单
*
* @param userId
* @return
*/
List<UserOrderVO> getUserOrders(Integer userId);

/**
* 提交订单
*
* @param orderRequest
*/
Result<Void> submitOrder(OrderRequest orderRequest);
}

OrderServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.koohub.demo01.mapper.OrderMapper;
import com.koohub.demo01.model.common.Result;
import com.koohub.demo01.model.common.ResultCode;
import com.koohub.demo01.model.comvert.OrderConvert;
import com.koohub.demo01.model.dict.OrderStatusEnum;
import com.koohub.demo01.model.entity.OrderEntity;
import com.koohub.demo01.model.entity.UserEntity;
import com.koohub.demo01.model.req.OrderRequest;
import com.koohub.demo01.model.vo.UserOrderVO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, OrderEntity> implements OrderService {

@Resource
private OrderConvert orderConvert;

@Resource
private UserService userService;


@Override
public List<UserOrderVO> getUserOrders(Integer userId) {
List<UserEntity> userEntityList = baseMapper.selectUserOrders(userId);
List<UserOrderVO> resultList = orderConvert.toUserOrderVOList(userEntityList);
return resultList;
}

/**
* 此处只演示最简单的事务保证机制
* 如果在微服务架构下,订单和库存可能不在同一个库,此时需要用到分布式事务 (如 Seata) 或可靠消息最终一致性。
* <p>
* 下单考虑的点:
* 1.原子操作和事务保障:订单主表与订单详情表(一对多)同步插入,用户资产扣减
* 2.使用订单状态机,防止状态乱跳
* 3.为了防止前端短时间内重复点击“提交”,或者网络重试导致生成两张订单:方案是前端请求带上一个由后端预先生成的 Token(防重令牌),后端存入Redis,第一次请求后立即销毁。
* 4.Redis 的 DECR(或 DECRBY)操作可以将压力从数据库转移到内存。这被称为“预扣减机制” 来保护数据库。
*
*
* Redis 的预扣机制:
* 预热阶段:活动开始前,将商品库存/用户限额同步到 Redis。
* 扣减阶段:请求进来,先在 Redis 执行 DECR。
* 判断阶段:如果结果 >= 0:Redis 扣减成功,说明“抢到了”,进入数据库真正下单;如果结果 < 0:Redis 库存不足,直接返回“已售罄”,请求不再下发给数据库。
* 补偿阶段:如果数据库下单失败(如事务回滚),需要将 Redis 里的数值 INCR 回去。
* |
* 为什么要用 Lua 脚本而不是直接用 decr?
* 虽然 Redis 的 DECR 是原子的,但你会面临这种情况:
* 你先 GET 发现库存是 1。你想扣2个,如果你直接 DECRBY 2,库存会变成 -1。 虽然你可以判断结果小于 0 就回滚,但 Lua 脚本可以将“检查”和“扣减”合并为一个原子步骤,避免 Redis 中出现大量的负数,逻辑更加严密。
* |
* 如果 Redis 扣减了,但数据库还没扣,此时服务器宕机怎么办?
* 保证数据一致性:对策是定期执行一个“对账”定时任务,通过数据库库存来校准 Redis 里的值。
* Redis宕机:对策是采用 Redis Sentinel(哨兵)或 Cluster(集群)保证高可用。如果 Redis 彻底挂了,可以通过配置开关直接切回数据库降级运行。
* |
* 保护数据库:将 99% 的无效请求拦截在 Redis 层。数据库只处理那 1% 真正能成功的请求。
* 降低响应耗时:Redis 的响应在毫秒级,前端可以迅速得到反馈,不需要等待复杂的数据库事务。
* 应对瞬时峰值:数据库的 TPS 通常在几千,而 Redis 轻松过 10 万。
*
*
* 分布式幂等锁:
* 【Redis 预扣减】,高并发下还容易产生“重复下单”问题。你想了解如何利用 Redis 的 setnx(set if not exists) 实现一个分布式幂等锁,防止用户一秒钟内点 10 次下单按钮
* 加锁:请求进来,先去 Redis 占坑。占到坑的(返回 1)继续执行业务。
* 互斥:没占到坑的(返回 0)说明重复请求正在处理中,直接拒绝。
* 超时释放:必须给 Key 设置过期时间(TTL),防止业务代码崩溃导致锁永远不释放(死锁)。
* 注意,早期的 Redis 需要分两步:先 setnx 再 expire。如果第一步成功第二步服务器挂了,会产生永久死锁。现代代码必须使用 setIfAbsent(key, val, time, unit),确保加锁和设置过期时间是一个原子操作。
* 你可能听过 Redisson。SETNX方案适合简单的“幂等性”校验,代码轻量。Redisson方案:适合复杂的“分布式并发控制”。它解决了锁续期(看门狗机制)和高可用集群下的红锁问题。如果是单纯为了防止“重复下单”,SETNX 已经绰绰有余。
*
* 在更复杂的架构中,我们会采用 【Token 机制】:
* 进入下单页面前,后端先给前端发一个 OrderToken 存入 Redis。
* 下单时,前端带上这个Token。后端校验 Token 存在则通过,并立即删除 Token。 这比 SETNX 更严谨,因为它是针对“特定某次点击”的。
*
*
* <p>
* 涉及的表:
* user:用户表,保存用户的基本信息和余额信息
* order:订单主表,核心字段 order_no, uid, total_amount, status。 一个订单通常对应多个商品,主表存总价和状态
* order_detail:订单明细表,核心字段 order_id, product_id, snapshot_price。详情表存快照(下单时的单价、商品名)。快照很重要,因为商品改价或下架后,历史订单必须保持下单时的价格和名称!
* product:商品库存表,核心字段 name, price, stock, version
* <p>
* 1. 基本校验
* a.校验user的状态、余额等
* b.校验product的状态、库存、是否下架等
* <p>
* 2.核心业务
* a.从后端获取数据计算订单总金额,并验证用户余额
* b.扣减库存 (乐观锁/数据库层 原子更新,采用“预扣库存”模式,防止超卖) UPDATE product SET stock = stock - #{count}, version = version + 1 WHERE id = #{id} AND stock >= #{count}
* <p>
* 3.插入订单主表
* 专业的分布式ID生成器(如雪花算法)
* <p>
* 4.批量插入订单详情
* <p>
* 5.异步逻辑 (可选):清理购物车、发送通知等 (通常不在主事务内,或者用 MQ)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> submitOrder(OrderRequest orderRequest) {
/*
数据库行锁:
UPDATE user SET balance = balance - #{amount}, version = version + 1 WHERE id = #{userId} AND balance >= #{amount} AND deleted = 0;
a.锁定记录:MySQL 会根据 WHERE id = #{userId} 准确定位到那一行。由于 id 是主键,InnoDB 会给该行加上 排他锁(X锁 / Record Lock)。
b.排他性:在当前事务未提交(Commit)之前,其他任何试图修改这一行的事务(例如另一个下单请求)都会进入 阻塞(Lock Wait) 状态。
c.原子更新:MySQL 在内部完成 balance - amount 的计算。由于是在锁内计算,不会出现两个线程同时改写导致数据覆盖(丢失更新)的问题。
d.释放锁:只有当 OrderServiceImpl 的整个 @Transactional 方法执行完毕(提交或回滚)后,行锁才会释放。

balance >= #{amount} 的妙用:
这一行代码解决了 “超卖/负数余额” 的终极问题
不仅仅是锁:行锁只负责让大家“排队”,但如果不加这个判断,排队的人还是会把余额扣成负数。
判断即原子:通过在 WHERE 子句中加入条件,MySQL 会在扣钱的一瞬间再次检查余额。如果余额不足,UPDATE 语句影响的行数(Affected Rows)将返回 0。
int updated = userMapper.reduceBalance(userId, amount);
if (updated == 0) {
// 此时事务会回滚,之前的任何操作(如库存扣减)都会撤销
throw new BusinessException("余额不足,下单失败");
}
*/
// 1. 扣减余额(利用数据库行锁保证原子性)
// 先查询一次余额。如果 balance < amount,直接返回错误,不开启事务!
UserEntity user = userService.getById(orderRequest.getUserId());
BigDecimal balance = user.getBalance();
BigDecimal amount = orderRequest.getAmount();
if (balance == null || balance.compareTo(amount) < 0) {
return Result.fail(ResultCode.FAILURE.getCode(), "余额不足,下单失败!");
}
int updated = userService.reduceBalance(orderRequest.getUserId(), orderRequest.getAmount());
if (updated == 0) {
throw new RuntimeException("余额不足,下单失败!");
}

// 2. 创建订单记录
OrderEntity order = new OrderEntity();
order.setUid(orderRequest.getUserId())
.setOrderNo(UUID.randomUUID().toString().replace("-", ""))
.setAmount(orderRequest.getAmount())
.setStatus(OrderStatusEnum.PAID); // 模拟支付完成
this.save(order);
log.info("用户 {} 下单成功,单号:{}", orderRequest.getUserId(), order.getOrderNo());
return Result.success();
}
}

OrderConvert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Mapper(config = MapStructConfig.class)
public interface OrderConvert {

/**
* 只需要一个入口方法
* 内部利用 @Mapping 的特殊配置来处理细节
*/
@Mapping(source = "orders", target = "orders") // 嵌套映射(即便类型不同,只要下面定义了转换逻辑或自动生成即可)
@Mapping(target = "username", expression = "java(user.getUsername() + \"(VIP)\")") // 使用 Java 表达式
UserOrderVO toUserOrderVO(UserEntity user);
List<UserOrderVO> toUserOrderVOList(List<UserEntity> userList);

@Mapping(source = "createTime", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "updateTime", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "status.code", target = "statusCode") // 核心:通过点语法获取枚举内的属性
@Mapping(source = "status.desc", target = "statusDesc")
UserOrderVO.OrderInfoVO toOrderInfoVO(OrderEntity orderEntity);
}

OrderMapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Mapper
public interface OrderMapper extends BaseMapper<OrderEntity> {

/**
* 查询用户的所有订单
*/
@Select("select id,uid,order_no,amount,status,create_time,update_time from zdemo01_order where uid = #{userId} and deleted = 0")
List<OrderEntity> selectByUserId(@Param("userId") Integer userId);

/**
* 查询用户及其订单(1-n):对于这种查询一个1,且一对多的查询,只发送2条sql
*/
@Select("select id, username, birthday, balance, create_time, update_time from zdemo01_user where id = #{userId} and deleted = 0")
@Results({
@Result(column = "id", property = "id"),
@Result(column = "username", property = "username"),
@Result(column = "birthday", property = "birthday"),
@Result(column = "balance", property = "balance"),
@Result(column = "id", property = "orders", javaType = List.class,
many = @Many(select = "com.koohub.demo01.mapper.OrderMapper.selectByUserId")),
})
List<UserEntity> selectUserOrders(@Param("userId") Integer userId);


/**
* 查询订单及其用户信息(1-1):对于这种多对一的查询,可能会发送很多条sql查询user,不建议使用;微服务一般建议在service层拼接!
*/
@Select("select id,uid,order_no,amount,status,create_time,update_time from zdemo01_order")
@Results({
@Result(column = "id", property = "id"),
@Result(column = "order_no", property = "orderNo"),
@Result(column = "amount", property = "amount"),
@Result(column = "status", property = "status"),
@Result(column = "uid", property = "user", javaType = UserEntity.class,
one = @One(select = "com.koohub.demo01.mapper.UserMapper.selectById")),
})
List<OrderEntity> listOrderAndUser();
}

OrderEntity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@TableName("zdemo01_order")
public class OrderEntity extends BaseEntity {
@TableId(type = IdType.AUTO)
private Long id;

private Integer uid;

private String orderNo;

private BigDecimal amount;

private OrderStatusEnum status;

@TableField(exist = false)
private UserEntity user;
}

OrderRequest

1
2
3
4
5
@Data
public class OrderRequest {
private Integer userId;
private BigDecimal amount;
}

UserOrderVO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
public class UserOrderVO {
private Long id;
private String username;
private Date birthday;
private List<OrderInfoVO> orders;

@Data
public static class OrderInfoVO {
private Long id;
private String orderNo;
private BigDecimal amount;

private Integer statusCode; // 前端友好展示
private String statusDesc;

private Date createTime;
private Date updateTime;
}
}


相关测试类

UserEntityMapperTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@SpringBootTest
class UserEntityMapperTest {

@Resource
private UserMapper userMapper;

@Test
void test01() {
// Date start = DateUtils.parseDate("2026-01-05", "yyyy-MM-dd");
List<Map<String, Object>> result = userMapper.selectCountByBirthYear();
log.info("test01 result:{}", result);
}

@Test
void test02() {
UserPageQueryRequest queryRequest = new UserPageQueryRequest();
queryRequest.setUsername("zhangsan");
Long result = userMapper.pageQueryCount(queryRequest);
log.info("test02 result:{}", result);
}
}

OrderEntityMapperTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
@SpringBootTest
class OrderEntityMapperTest {

@Resource
private OrderMapper orderMapper;

@Test
void test01() {
List<UserEntity> result = orderMapper.selectUserOrders(1);
log.info("test01 result:{}", result);
}

@Test
public void test02() {
List<OrderEntity> result = orderMapper.listOrderAndUser();
log.info("test02 result:{}", result);
}
}


构建动态多数据源的应用

配置文件的变动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 删除原来 datasource 的相关配置,增加如下配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
master:
url: jdbc:mysql://xxx:3306/zdemo?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: xxx
password: xxx
slave:
enabled: true
url: jdbc:mysql://xxx:3306/edu?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: xxx
password: xxx
initialSize: 5
minIdle: 10
maxActive: 20
maxWait: 60000
connectTimeout: 30000
socketTimeout: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
maxEvictableIdleTimeMillis: 900000
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: true # http://localhost:8848/druid
allow:
url-pattern: /druid/*
login-username: owlias
login-password: xxx
filter:
stat:
enabled: true
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
config:
multi-statement-allow: true


相关配置类和组件

DruidConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
import com.alibaba.druid.support.jakarta.StatViewServlet;
import com.alibaba.druid.util.Utils;
import jakarta.servlet.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
* @author KJ
* @description druid 多数据源
*/
@Slf4j
@Configuration
@EnableConfigurationProperties({DruidStatProperties.class, DataSourceProperties.class}) // 显式启用 DruidStatProperties 的绑定,确保它能先被准备好
public class DruidConfig {

@Bean(autowireCandidate = false) // 虽然实例化了这个 Bean,但它会告诉容器:在自动注入(Autowire)时请忽略我,我不是候选人。
@ConfigurationProperties("spring.datasource.druid.master")
public DataSource masterDataSource(DruidProperties druidProperties) {
DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
return druidProperties.dataSource(dataSource);
}

@Bean(autowireCandidate = false)
@ConfigurationProperties("spring.datasource.druid.slave")
@ConditionalOnProperty(prefix = "spring.datasource.druid.slave", name = "enabled", havingValue = "true") //
public DataSource slaveDataSource(DruidProperties druidProperties) {
DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
return druidProperties.dataSource(dataSource);
}

/**
* 优先使用这个动态数据源 DynamicDataSource(bean名称为dataSource)
*/
@Bean(name = "dataSource")
@Primary
public DynamicDataSource dataSource(DruidProperties druidProperties) {
// masterDataSource 先被注册,slaveDataSource 作为备选注册
Map<Object, Object> targetDataSources = new HashMap<>();
DataSource masterDataSource = masterDataSource(druidProperties);
targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
try {
// 注意:这里调用 slaveDataSource 时,如果 @Conditional 条件不满足,可能会抛异常或返回 null(取决于具体代理逻辑),我们做一层防护
DataSource slaveDataSource = slaveDataSource(druidProperties);
if (slaveDataSource != null) {
targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
}
} catch (Exception e) {
log.info("从数据库未配置或未开启,仅加载主库。");
}
return new DynamicDataSource(masterDataSource, targetDataSources);
}

/**
* 在 DruidConfig 类中添加此 Bean
*/
@Bean
@ConditionalOnProperty(name = "spring.datasource.druid.stat-view-servlet.enabled", havingValue = "true")
public static ServletRegistrationBean<StatViewServlet> druidStatViewServlet() {
// 注册控制台的 Servlet,注意此处使用的是 Druid 适配 Jakarta 的版本
ServletRegistrationBean<StatViewServlet> registrationBean = new ServletRegistrationBean<>(new StatViewServlet(), "/druid/*");
// 添加初始化参数 (可选)
registrationBean.addInitParameter("loginUsername", "xxx");
registrationBean.addInitParameter("loginPassword", "xxx");
registrationBean.addInitParameter("resetEnable", "false"); // 禁用 HTML 页面上的“Reset All”功能
return registrationBean;
}

/**
* 去除监控页面底部的广告
*/
@Bean
@ConditionalOnProperty(name = "spring.datasource.druid.stat-view-servlet.enabled", havingValue = "true")
public static FilterRegistrationBean<Filter> removeDruidFilterRegistrationBean(DruidStatProperties druidProperties) {
// 1. 获取监控页面配置路径
DruidStatProperties.StatViewServlet config = druidProperties.getStatViewServlet();
String pattern = (config != null && config.getUrlPattern() != null) ? config.getUrlPattern() : "/druid/*";
// 移除末尾通配符并拼接 common.js
String commonJsPattern = pattern.replaceAll("\\*", "") + "js/common.js";

// 2. 创建基于 jakarta.servlet.Filter 的过滤器
Filter filter = getFilter();

// 3. 注册过滤器
FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(filter);
registrationBean.addUrlPatterns(commonJsPattern);
// 提高优先级,确保在 Shiro 或其他可能影响静态资源的过滤器之前或之后执行
registrationBean.setOrder(Integer.MIN_VALUE);
return registrationBean;
}

private static Filter getFilter() {
final String filePath = "support/http/resources/js/common.js";

// 直接处理 common.js 请求,不执行 chain.doFilter
// 这样可以完全控制返回内容,绕过 Druid 默认的资源输出
return new Filter() {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 直接处理 common.js 请求,不执行 chain.doFilter
// 这样可以完全控制返回内容,绕过 Druid 默认的资源输出
String text = Utils.readFromResource(filePath);

// 匹配 buildFooter 函数体并清空
/*text = text.replaceAll("<a.*?banner\"></a><br/>", "");
text = text.replaceAll("powered.*?shrek.wang</a>", "");*/
text = text.replaceAll("buildFooter\\s*:\\s*function\\s*\\(\\)\\s*\\{[\\s\\S]*?}(?=\\s*,\\s*|\\s*})", "buildFooter: function() {}");
text = text.replaceAll("<a.*?druid_banner_click.*?>.*?</a><br/>", "");

response.setCharacterEncoding("UTF-8");
response.setContentType("application/javascript");
response.getWriter().write(text);
}
};
}
}

DruidProperties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* @author KJ
* @description druid连接池基础属性
*/
@Data
@ConfigurationProperties(prefix = "spring.datasource.druid")
@Component
public class DruidProperties {
private int initialSize;
private int minIdle;
private int maxActive;
private int maxWait;
private int connectTimeout;
private int socketTimeout;
private int timeBetweenEvictionRunsMillis;
private int minEvictableIdleTimeMillis;
private int maxEvictableIdleTimeMillis;
private String validationQuery;
private boolean testWhileIdle;
private boolean testOnBorrow;
private boolean testOnReturn;

public DruidDataSource dataSource(DruidDataSource datasource) {
datasource.setInitialSize(initialSize);
datasource.setMaxActive(maxActive);
datasource.setMinIdle(minIdle);
datasource.setMaxWait(maxWait);
datasource.setConnectTimeout(connectTimeout);
datasource.setSocketTimeout(socketTimeout);
datasource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
datasource.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis);
datasource.setMaxEvictableIdleTimeMillis(maxEvictableIdleTimeMillis);
datasource.setValidationQuery(validationQuery);
datasource.setTestWhileIdle(testWhileIdle);
datasource.setTestOnBorrow(testOnBorrow);
datasource.setTestOnReturn(testOnReturn);
return datasource;
}
}

DynamicDataSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author KJ
* @description 采用的动态可切换的数据源,继承自 spring AbstractRoutingDataSource,采用绑定绑定本地线程变量的方式进行动态切换
*/
public class DynamicDataSource extends AbstractRoutingDataSource {

public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
super.setDefaultTargetDataSource(defaultTargetDataSource);
super.setTargetDataSources(targetDataSources);
super.afterPropertiesSet();
}

@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceType();
}
}

DynamicDataSourceContextHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* @author KJ
* @description 数据源切换组件
*/
@Slf4j
public class DynamicDataSourceContextHolder {
/**
* 数据源名称
*/
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();

/**
* 切换数据源
*/
public static void setDataSourceType(String dataSource) {
log.info("切换数据源:{}", dataSource);
CONTEXT_HOLDER.set(dataSource);
}

/**
* 获取数据源名称
*/
public static String getDataSourceType() {
return CONTEXT_HOLDER.get();
}

/**
* 清空数据源
*/
public static void clearDataSourceType() {
CONTEXT_HOLDER.remove();
}
}

DataSourceType

1
2
3
4
5
6
7
/**
* @author KJ
* @description 数据源种类枚举
*/
public enum DataSourceType {
MASTER, SLAVE;
}

DataSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author KJ
* @description 数据源切换注解,优先级先方法后类,如果方法覆盖了类上的数据源类型,以方法的为准,否则以类上的为准
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DataSource {

/**
* 切换数据源名称(默认数据源 MASTER)
*/
DataSourceType value() default DataSourceType.MASTER;
}


切面配置

增加依赖:

1
2
3
4
5
6
<!--AOP-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>${spring-boot.version}</version>
</dependency>

DataSourceAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* @author KJ
* @description 多数据源切面
*/
@Slf4j
@Aspect
@Order(20)
@Component
public class DataSourceAspect {

@Pointcut("@annotation(com.koohub.demo01.config.datasource.DataSource) || " +
"@within(com.koohub.demo01.config.datasource.DataSource)")
public void dataSourcePointCut() {
}

@Around("dataSourcePointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
DataSource dataSource = getDataSource(point);
if (Objects.nonNull(dataSource)) {
// 切换数据源标识
DynamicDataSourceContextHolder.setDataSourceType(dataSource.value().name());
}
try {
return point.proceed();
} finally {
// 在执行方法之后,销毁数据源标识
DynamicDataSourceContextHolder.clearDataSourceType();
}
}

/**
* 获取需要切换的数据源标识
*/
public DataSource getDataSource(ProceedingJoinPoint point) {
MethodSignature signature = (MethodSignature) point.getSignature();
DataSource dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), DataSource.class);
if (Objects.nonNull(dataSource)) {
return dataSource;
}
return AnnotationUtils.findAnnotation(signature.getDeclaringType(), DataSource.class);
}
}


相关业务类

DictController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
public class DictController {

@Resource
private DictService dictService;

@GetMapping(value = "/dict/{id}")
public Result<DictVO> getUserById(@Min(value = 1, message = "ID不合法") @Parameter(description = "字典ID") @PathVariable Long id) {
DictVO dictVo = dictService.getDictById(id);
return Result.success(dictVo);
}

@GetMapping(value = "/dict/getByType")
public Result<List<DictVO>> getByType(@Parameter String type) {
List<DictVO> dictList = dictService.getDictListByType(type);
return Result.success(dictList);
}
}

DictService

1
2
3
4
public interface DictService {
DictVO getDictById(Long id);
List<DictVO> getDictListByType(String type);
}

DictServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Slf4j
@Service
public class DictServiceImpl extends ServiceImpl<DictMapper, DictEntity> implements DictService {

@Resource
private DictConvert dictConvert;

/**
* 切换数据源:方式一
*/
@DataSource(DataSourceType.SLAVE)
@Override
public DictVO getDictById(Long id) {
DictEntity dictEntity = baseMapper.selectById(id);
return dictConvert.toDictVO(dictEntity);
}

/**
* 切换数据源:方式二
*/
@Override
public List<DictVO> getDictListByType(String type) {
try {
DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.SLAVE.name());
LambdaQueryWrapper<DictEntity> query = new LambdaQueryWrapper<>();
query.eq(DictEntity::getType, type).orderByAsc(DictEntity::getSort);
List<DictEntity> dictEntities = baseMapper.selectList(query);
return dictConvert.toDictVOList(dictEntities);
} finally {
DynamicDataSourceContextHolder.clearDataSourceType();
}
}
}

DictConvert

1
2
3
4
5
@Mapper(config = MapStructConfig.class)
public interface DictConvert {
DictVO toDictVO(DictEntity dict);
List<DictVO> toDictVOList(List<DictEntity> dictList);
}

DictMapper

1
2
3
@Mapper
public interface DictMapper extends BaseMapper<DictEntity> {
}